手写一个 Redux(一)

实现 Redux

这个系列分为 3 个部分(这篇文章是第 1 部分):

  1. 最基本简单的 Redux 实现,以及如何与 React 结合使用
  2. 实现 React-Redux
  3. 增强 Redux 的实现,包括拆分合并 reducer,中间件等

Redux 的三大原则:

单一状态树

单一状态树,意味着只有一个 store 对象来保存应用程序的状态。这样的好处包括:易于 debug,实现数据持久化,实现诸如时间旅行等功能。

状态是只读的

状态只能通过触发 action 来改变,action 可以理解为一种改变状态的意图,它是一个类似这样的 JavaScript 对象:

1
2
3
4
{
type: 'ADD_TODO',
text: 'Learn Redux'
}

其中的 type 属性是必须要有的,其他的是可选属性。

通过这种形式,所有能改变状态的情形都被集中管理了,并且严格按照一定的顺序来执行。

As actions are just plain objects, they can be logged, serialized, stored, and later replayed for debugging or testing purposes.

状态只能通过纯函数来改变

action 是指意图,那么意图的具体内容是什么,则是由 reducer 来指定的。 reducer 是一个纯函数。入参是应用程序的前一个状态,我们不应该修改入参,而是应该返回一个新的状态。一个纯函数必须满足以下两个条件:

  • 相同的输入,每次都得到相同的输出。
  • 不应该产生副作用,包括但不限于:更改入参,修改局部状态,发起网络请求,读写数据库,触发事件,打印日志等。

纯函数与非纯函数举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 非纯函数,由于 Math.random() 存在,每次得到的输出是不一样的
const getRandomNum = x => x + Math.random();
// 非纯函数,每次得到的输出是不一样的
const getTime = () => Date.now();
// 非纯函数,依赖外部变量 base
let base = 9;
const getNumber = x => base + x;
// 非纯函数
const getUsers = async id => {
const response = await fetch(`https://example.com/user?id=${id}`);
const json = await response.json();
return json;
};

// 纯函数
const square = x => x * x;

基本实现,不考虑视图层

先不考虑拆分 reducer,中间件,以及一些边缘情况,只考虑最常见的使用情形,Redux 的核心是下面的几个函数:

  • createStore(reducer) 入参是 reducer,返回一个 store 对象。该对象包含以下 3 个方法
  • getState() 返回一个新的状态对象
  • subscribe(callback) 订阅一个回调函数,当状态变化时,该回调会被执行
  • dispatch(action) 分发 action

Redux 的使用者编写的代码主要是 reducer,来看看 reducer 函数的签名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const reducer = (state = { todos: [] }, action) => {
switch (action.type) {
case 'ADD_TODO':
return {
todos: [
{ id: action.id, text: action.text, completed: false },
...state.todos,
],
};
case 'REMOVE_TODO':
return {
todos: state.todos.filter(todo => todo.id !== action.id),
};
case 'TOGGLE_TODO':
return {
todos: state.todos.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
),
};
default:
return state;
}
};

我们试着使用一下上面定义的 reducer:

1
2
3
4
5
const state0 = reducer(undefined, {
type: 'ADD_TODO',
id: 1,
text: 'Learn JavaScript',
});

现在 state0 的状态如下:

1
2
3
{
todos: [{ id: 1, text: 'Learn JavaScript', completed: false }];
}

再来一次:

1
2
3
4
const state1 = reducer(state0, {
type: 'TOGGLE_TODO',
id: 1,
});

现在 state1 的状态如下:

1
2
3
{
todos: [{ id: 1, text: 'Learn JavaScript', completed: true }];
}

当然,持续的创建这么多变量来存储某个中间状态没有必要,上面的两步可以用下面的方式实现:

1
2
3
4
5
6
const actions = [
{ type: 'ADD_TODO', id: 1, text: 'Learn JavaScript' },
{ type: 'TOGGLE_TODO', id: 1 },
];

const state = actions.reduce(reducer, undefined);

注意上面的 reduce 函数的使用,这也是 reducer 被叫做 reducer 的原因。

可以想象,createStore(reducer) 函数的雏形大致如下:

1
2
3
4
5
6
7
8
9
10
// v0.0
const createStore = reducer => {
let state = undefined;
return {
getState: () => state,
dispatch: action => {
state = reducer(state, action);
},
};
};

尝试下使用我们的 createStore,传入之前定义的 reducer

1
2
3
4
5
6
7
const store = createStore(reducer);
store.dispatch({ type: 'ADD_TODO', id: 1, text: 'Learn React' });
store.getState();
// state -> todos: [{ id: 1, text: 'Learn React', completed: false }]
store.dispatch({ type: 'TOGGLE_TODO', id: 1 });
store.getState();
// state -> todos: [{ id: 1, text: 'Learn React', completed: true }]

按照上面的代码,我们创建的 store 已经可以如预期般运行了,但是还有 2 点需要改进:

第 1 点是每次分发 action 改变了状态后,我们需要手动调用 getState() 才能得到更新后的状态,所以可以考虑在返回的对象中,添加一个订阅函数,每当状态更新后即触发该订阅函数自动执行。

第 2 点是需要对 action 做一些验证,比如 action 必须是一个对象,并且必须包含一个 type 属性等。

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
///////////////////////////////
// Mini Redux implementation //
///////////////////////////////
const validateAction = action => {
if (!action || typeof action !== 'object' || Array.isArray(action)) {
throw new Error('Action must be an object');
}

if (typeof action.type === 'undefined') {
throw new Error('Action must hava a type');
}
};

const createStore = reducer => {
let state = undefined;
let subscribers = [];

const store = {
getState: () => state,
subscribe: handler => {
subscribers.push(handler);
return () => {
const index = subscribers.findIndex(handler);
if (index > -1) {
subscribers.splice(index, 1);
}
};
},
dispatch: action => {
validateAction(action);
subscribers.forEach(sub => sub());
state = reducer(state, action);
},
};
store.dispatch({ type: '@@redux/INIT' });
return store;
};

//////////////////////
// Our action types //
//////////////////////
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const REMOVE_TODO = 'REMOVE_TODO';

/////////////////
// Our reducer //
/////////////////
const initialState = { todos: [], nextTodoId: 1 };
const reducer = (state = initialState, action) => {
switch (action.type) {
case ADD_TODO:
return {
...state,
todos: [
{ id: state.nextTodoId, text: action.text, completed: false },
...state.todos,
],
nextTodoId: state.nextTodoId + 1,
};
case TOGGLE_TODO:
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
),
};
case REMOVE_TODO:
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.id),
};
default:
return state;
}
};

///////////////
// Our store //
///////////////
const store = createStore(reducer);

///////////////////////////////////////////////
// Render our app whenever the store changes //
///////////////////////////////////////////////
store.subscribe(() => {
ReactDOM.render(
<pre>{JSON.stringify(store.getState(), null, 2)}</pre>,
document.getElementById('root')
);
});

//////////////////////
// Dispatch actions //
//////////////////////
store.dispatch({
type: ADD_TODO,
text: 'Hello Redux',
});

store.dispatch({
type: TOGGLE_TODO,
id: 1,
});

store.dispatch({
type: REMOVE_TODO,
id: 1,
});

与 React 结合使用

上面的代码订阅了 () => ReactDOM.render(),每当状态发生变化时,会在页面上重新渲染更新后的状态内容。接着我们更进一步,将 React 组件与上面实现的 Mini Redux 一起使用。另外,Redux 并非只能与 React 一同使用,其他视图库也能与 Redux 结合使用。比如 Vue 自己实现的状态管理库 Vuex 其实跟 Redux 基本是一个东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
////////////////////////////////
// Mini Redux work with React //
////////////////////////////////
import React from 'react';
import ReactDOM from 'react-dom';

// 此处省略上面的 createStore 及 reducer 这 2 个函数的代码
const store = createStore(reducer);

// 这里为了方便,没有做组件拆分
class TodoApp extends React.Component {
constructor(props) {
super(props);
this.state = {
...props.store.getState(),
newTodoText: '',
};
}

componentDidMount() {
this.unsubscribe = this.props.store.subscribe(() =>
this.setState(this.props.store.getState())
);
}

componentWillUnmount() {
this.unsubscribe();
}

addTodo = () => {
this.props.store.dispatch({
type: ADD_TODO,
text: this.state.newTodoText,
});
};

toggleTodo = id => {
this.props.store.dispatch({
type: TOGGLE_TODO,
id,
});
};

removeTodo = id => {
this.props.store.dispatch({
type: REMOVE_TODO,
id,
});
};

render() {
const { newTodoText, todos } = this.state;
return (
<div>
<input
value={newTodoText}
onChange={event => this.setState({ newTodoText: event.target.value })}
type="text"
/>
<button onClick={this.addTodo}>Add todo</button>
<ul>
{todos.map(todo => (
<li
key={todo.id}
style={{
display: 'flex',
width: '500px',
margin: '20px',
borderBottom: '1px solid #ccc',
}}
>
<span
onClick={() => this.toggleTodo(todo.id)}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
flex: 1,
}}
>
{todo.text}
</span>
<span onClick={() => this.removeTodo(todo.id)}>×</span>
</li>
))}
</ul>
</div>
);
}
}

ReactDOM.render(<TodoApp />, document.getElementById('root'));

好了,一个最简单原始的 Mini Redux 实现了,借助 create-react-app 脚手架,上面的代码应该可以与 React 相结合并正常运行。

但是可以看出,Redux 本身只专注于状态层,负责以可预测的方式集中管理状态。如果需要和视图层绑定,则还有不少工作要做。

以上面的代码为例,我们需要做几件事:

  1. 在组件初始化时,将组件所依赖的状态(在这里是 todosnextTodoId 这两个属性)注入(通过 props 属性的方式传入)到组件本身的状态里。
  2. 在组件加载后,订阅 store 状态的变化,每当状态更新,就会调用 this.setState(this.props.store.getState()),使得组件状态与 store 状态同步。与此同时,将订阅的返回值保存为一个实例 this.unsubscribe,方便取消订阅。
  3. 每当组件内由于用户交互等方式,需要更新状态时,调用 this.props.dispatch(action) 更新 store 内的状态。由于第 2 步的订阅,组件的状态也相应地更新。
  4. 在组件卸载时,取消订阅。

这些逻辑应该可以被抽取成更加通用的代码以便复用。实际上,React-Redux 这个库就是提供了一套更加通用的方法来实现 Redux 和 React 的绑定。手动实现 React-Redux 见下一篇

参考链接: